원본 항목: Part 5. 코드 오류 — 5. 스택 변수 주소 리턴CWE-562repo 폴더: sd47_stackret/ (빌드·실행은 §3-1)
목표 시간: 1회차 12분 / 2회차 7분 / 3회차 5분진짜 UB(지역 변수 주소 반환)를 역참조해서 크래시로 재현한다.
gcc 는 return p;(지역 배열 주소)를 컴파일 타임에 NULL 반환으로 바꿔 버리므로(clang 의 ASan stack-use-after-return 과 다르다), 크래시는 직접 바이트 역참조(p[0])할 때 NULL 역참조(SEGV)로 나타난다.
printf("%s", p) 처럼 문자열 전체를 넘기는 경로는 glibc 가 (null) 로 찍고 지나가므로 크래시가 재현되지 않는다는 것 자체가 이 드릴의 숨은 교훈이다.
이 드릴의 문서 구성 (SD01~SD58 공통)
① 요구사항서 — 어떤 코드가 뚫려 있고, 무엇을 만드나.
② pseudo code 템플릿 — Bad 와 Good 의 뼈대이고, 회차마다 가린다.
③ 테스트 코드 — 공격은 막히고 정상은 통과해야 끝이다.
④ 자주 하는 실수
⑤ 모범답안 — 맨 뒤에 접어 둔다.
변형 A/B/C 는 같은 치환 규칙을 다른 소재로 다시 치는 것이다.
0. 이 드릴로 체화할 것
char *rpl(void) { char p[10]; strcpy(p, "SECRET123");- return p; /* 함수 종료 시 소멸되는 주소 */+ char *buf = (char *)malloc(10);+ if (!buf) return NULL;+ memcpy(buf, p, 10);+ return buf; /* 함수 종료 후에도 유효, 호출자가 free */ }
지역 배열은 함수가 끝나는 순간 사라지는 임시 좌표일 뿐이다.
그 주소를 밖으로 내보내는 순간, 받은 쪽은 이미 무효한 메모리를 붙들게 된다.
회차별 진행
회차
변형
보는 것
순서
1회차
A 임시 문자열 조립 함수
① 요구사항서 ② pseudo code 전체
1. Bad 를 그대로 쳐서 취약점을 관찰한다. 2. Good pseudo 를 한 줄씩 코드로 바꾼다. 3. ③ 테스트를 실행한다.
2회차
B 호출자 제공 버퍼 방식으로 변형
① 요구사항서 ② pseudo code 의 번호와 제목만 (Level 2)
1. Bad 를 치고 취약점을 확인한다. 2. 번호만 보고 Good 을 쓴다. 3. ③ 테스트를 실행한다.
3회차
Cstatic 버퍼로 “고친” 척하는 함정
① 요구사항서만
1. ③ 테스트를 먼저 읽는다. 2. 백지에서 Bad 부터 Good 까지 재현한다.
1회차에 막히면 같은 변형을 한 번 더 친다.
3회차를 통과하면 A 를 백지로 다시 친다.
char *rpl_good(void){ /* 아무것도 안 본다. * §1 요구사항서와 §0 의 치환 규칙만 보고 친다. * * 막히면 아래만 찾아본다. * - 핵심은 지역 변수를 없애는 게 아니라 **밖으로 나가는 주소가 어디인가** * - 호출자가 `free` 하는 것까지 시험에 넣는다 */}
막혔을 때 찾는 순서
컴파일 에러가 나면 에러 문구를 본다.
unknown type name / implicit declaration 이면 include 누락이다.
man 3 <함수> 를 연다.
SYNOPSIS 맨 위의 #include 를 확인한다.
로직이 막히면 §3 테스트의 기대값을 먼저 읽는다.
공격 입력표(§1)와 테스트가 답이다.
#define _DEFAULT_SOURCE#include <stdio.h>#include <string.h>#include <stdlib.h>#include <sys/wait.h>#include <unistd.h>#include "stackret.h"static int g_fail = 0;#define T_TRUE(cond, msg) \ do { if (!(cond)) { g_fail++; \ printf(" X %s:%d %s\n", __func__, __LINE__, msg); } } while (0)static int expect_crash(void (*fn)(void)){ pid_t pid = fork(); if (pid == 0) { fn(); _exit(0); } int status = 0; waitpid(pid, &status, 0); if (WIFSIGNALED(status)) return 1; if (WIFEXITED(status) && WEXITSTATUS(status) != 0) return 1; return 0;}static void call_bad(void){ char *p = rpl_bad(); char c = p[0]; /* 한 바이트라도 직접 역참조해야 ASan이 감지한다 */ (void)c;}static void test_bad_is_vulnerable(void){ T_TRUE(expect_crash(call_bad) == 1, "Bad가 반환한 포인터를 역참조하면 크래시해야 한다(취약점 재현)");}static void test_good_is_valid_after_return(void){ char *g = rpl_good(); T_TRUE(g != NULL, "Good은 힙 할당에 성공해야 한다"); T_TRUE(strcmp(g, "SECRET123") == 0, "Good이 반환한 메모리는 함수 종료 후에도 내용이 그대로 유지돼야 한다"); free(g);}int main(void){ test_bad_is_vulnerable(); test_good_is_valid_after_return(); printf(g_fail ? "FAIL %d\n" : "PASS\n", g_fail); return g_fail ? 1 : 0;}
rpl_bad를 그대로 타이핑하면 컴파일이 안 될 수 있다
return p;(지역 배열 반환)는 gcc 가 -Wreturn-local-addr 로 컴파일 타임에 경고한다.
이 프로젝트는 -Werror 라 경고가 곧 빌드 실패다.
SD38에서 썼던 것과 같은 방식으로, rpl_bad 함수 앞뒤를 #pragma GCC diagnostic push / ignored "-Wreturn-local-addr"(와 -Wdangling-pointer) / pop 으로 감싸 “의도적으로 재현한 버그”라고 컴파일러에 알린다.
왜 직접 p[0]으로 읽어야 크래시가 나는가
printf("%s", p)처럼 문자열 전체를 한 번에 다루는 라이브러리 함수는 glibc 가 NULL 을 (null) 로 처리하거나, ASan 이 계측하지 않은 최적화된 경로(SIMD 스캔 등)를 탈 수 있어, 무효한 포인터를 읽고 있어도 크래시 없이 지나간다.
반면 p[0]처럼 컴파일러가 직접 생성한 단순 메모리 접근은 gcc 에서는 NULL 역참조(SEGV)로, ASan 이 심어둔 검사 코드가 있는 clang 에서는 stack-use-after-return 으로 반드시 걸린다.
“크래시가 재현이 안 됐다”가 “버그가 없다”의 증거가 아니라는 것 자체가 이 항목의 핵심 위험이다.
#include <string.h>#include <stdlib.h>#include "stackret.h"#pragma GCC diagnostic push#pragma GCC diagnostic ignored "-Wpragmas"#pragma GCC diagnostic ignored "-Wreturn-local-addr"#pragma GCC diagnostic ignored "-Wdangling-pointer"char *rpl_bad(void){ char p[10]; strcpy(p, "SECRET123"); return p; /* 스택 지역 배열의 주소 반환 -- 함수 종료 시 소멸 */}#pragma GCC diagnostic popchar *rpl_good(void){ char p[10]; char *buf = (char *)malloc(10); if (!buf) return NULL; strcpy(p, "SECRET123"); memcpy(buf, p, 10); return buf; /* 힙에 있으므로 함수 종료 후에도 유효 -- 호출자가 free */}
눈여겨볼 점: rpl_good은 여전히 지역 배열 p를 쓰지만, 반환하는 것은 p가 아니라 buf(힙 주소)다.
지역 변수 자체를 없애는 게 핵심이 아니라, **“함수 밖으로 나가는 주소가 어디를 가리키는가”**가 핵심이라는 걸 이 한 줄 차이(return p; vs return buf;)로 체감한다.